Skip to content

Move a draft card between boards - #20

Merged
eirvandelden merged 7 commits into
mainfrom
move-draft-card-between-boards
Nov 6, 2026
Merged

eirvandelden merged 7 commits into
mainfrom
move-draft-card-between-boards

Conversation

@eirvandelden

Copy link
Copy Markdown
Owner

Summary

Etienne presses "Create card", then switches the card to another board. Production raises ActiveRecord::RecordNotUnique: UNIQUE constraint failed: cards.board_id, cards.creator_id and the move fails.

The cause: the production SQLite database has a partial unique index, index_cards_on_board_creator_drafted, on cards(board_id, creator_id) WHERE status = 'drafted'. It allows one draft per user per board. A move fails when the user already has a draft on the target board. The index is in no migration and no commit. It is only in the uncommitted db/schema_sqlite.rb dump of the live database. Upstream basecamp/fizzy has no such index.

A draft card moves to any board the user can access, including a board where the user already has a draft. Behaviour matches upstream.

Implementation

Domain terms

  • Draft: a card with status = 'drafted' (Card::Statuses). Existing term.
  • Move: Card#move_to(board), which changes the board of a card. Existing term.
  • Drafted card index (new): the production-only partial unique index index_cards_on_board_creator_drafted on cards(board_id, creator_id) WHERE status = 'drafted'.

The repository has no glossary file. The new term is not written to a glossary in this change (see Design decisions).

Diagrams

classDiagram
  class Card {
    +move_to(new_board) void
  }
  class Board
  class User {
    +draft_new_card_in(board) Card
  }
  class RemoveBoardCreatorDraftedIndexFromCards {
    +up() void
  }
  Card --> Board : belongs to
  Card --> User : created by
  RemoveBoardCreatorDraftedIndexFromCards ..> Card : drops drafted card index on cards
Loading
sequenceDiagram
  participant T as Migration test (SQLite only)
  participant DB as connection
  participant M as RemoveBoardCreatorDraftedIndexFromCards
  participant C as Card
  T ->> DB : add_index drafted card index
  T ->> M : migrate(:up)
  M ->> DB : remove_index name, if_exists: true
  T ->> C : move_to(boards(:private))
  C ->> DB : UPDATE cards, events, comment events
  Note over T: the test transaction rolls back the DDL
Loading

ER: not affected. Columns and committed indexes do not change. Only the version line of both schema files changes.

Signatures

db/migrate/20261102120000_remove_board_creator_drafted_index_from_cards.rb — drops the drafted card index where it exists.

class RemoveBoardCreatorDraftedIndexFromCards < ActiveRecord::Migration[8.2]
  def up
    remove_index :cards, name: "index_cards_on_board_creator_drafted", if_exists: true
  end
end

No down: the inherited Migration#down does nothing, so a rollback succeeds and creates nothing. That matches the committed schema, which never had the index.

Card, Cards::BoardsController, User::Accessor: not affected.

Test class RemoveBoardCreatorDraftedIndexFromCardsTest < ActiveSupport::TestCase (new file test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb):

  • Class-level if ActiveRecord::Base.connection_db_config.adapter == "sqlite3" block that defines the three index-present tests. It reads the config and opens no connection before the parallel fork. It does not use Fizzy.db_adapter.sqlite?, which reads only DATABASE_ADAPTER and is wrong in SaaS mode, where the connection is always MySQL.
  • Private helpers:
    • add_drafted_card_index — connection.add_index :cards, [ :board_id, :creator_id ], unique: true, where: "status = 'drafted'", name: "index_cards_on_board_creator_drafted".
    • migrate_up — finds the migration by class name in connection_pool.migration_context.migrations, fails the test when it is missing, and runs migrate(:up) inside ActiveRecord::Migration.suppress_messages. The lookup is lazy, so a missing migration fails only these tests, not the suite load.
    • drafted_card_index? — connection.index_name_exists?(:cards, "index_cards_on_board_creator_drafted").
    • kevin_drafts_on_writebook_and_private — users(:kevin).draft_new_card_in(boards(:writebook)) and users(:kevin).draft_new_card_in(boards(:private)), the production path.

Departure: the migration carries no #: comment, because the repository's rubocop (Layout/LeadingCommentSpace) rejects it and upstream style has none.

Not checked: Fizzy has no Steep or rbs_rails setup; the #: comments document the types only.

Complexity

  • Migration, index absent: a fixed number of schema queries to list the indexes on cards. No DDL.
  • Migration, index present: the same schema queries plus one DROP INDEX. Cost does not depend on the row count of cards.
  • Card#move_to and its after_update board-change callbacks: unchanged. Writes to cards get slightly cheaper without the index.
  • No query-count claim, so no query-count test.

Invariants

  • I1. After the migration, cards has no index named index_cards_on_board_creator_drafted. Enforced by the migration.
  • I2. The migration runs no DDL and does not raise on a database without the index. Enforced by if_exists: true. On MySQL this also means no implicit commit inside the test transaction.
  • I3. One creator may hold more than one draft on one board after a move. Enforced by the absence of any constraint in the committed db/schema.rb and db/schema_sqlite.rb, as upstream.
  • I4. The test setup reproduces the production failure: with the index present and no migration, a move raises RecordNotUnique on cards.board_id, cards.creator_id. Enforced by a characterization test.
  • I5. Every DDL statement a test runs rolls back with that test's transaction. Enforced by defining the index-present tests only on sqlite3, where DDL is transactional and each parallel worker has its own database file.
  • I6. Both schema files carry version 2026_11_02_120000, equal to the new migration. Otherwise maintain_test_schema! and db:setup in CI raise PendingMigrationError.

Alternatives

  • A. Minimal: one migration test file; the adapter guard sits inside the index-creating helper, so every test runs on both adapters. Rejected: on MySQL the removal and post-migration tests would pass without the index and prove nothing, and the characterization test, which expects RecordNotUnique, would fail. So all index-present tests must be defined only on SQLite.

  • B. Maximal: a shared DraftedCardIndexTestHelper module in test/test_helpers, a partial_index_test class macro, raw-SQL index creation, a request-level regression test with the index and seven tests across three files. Rejected: too much test machinery for a one-line migration; only one test class needs the helpers.

  • C. Chosen: one migration test file with private helpers and an adapter-scoped block for the three index-present tests, plus two request-level tests in the existing controller test that run on both adapters. It keeps the characterization test from B and the small footprint from A.

  • Also rejected: change with column:, unique: and where:. Its inverse is add_index ... if_not_exists, and on MySQL that drops where: and creates a full unique index on rollback. change with name: only raises IrreversibleMigration on rollback. An explicit down that recreates the index puts back a defect no commit ever introduced.

  • Also rejected (intent Decisions): reworking Card#move_to instead of dropping the index.

  • db/migrate/20261102120000_remove_board_creator_drafted_index_from_cards.rb — new migration, drops the drafted card index where it exists.

  • db/schema.rb — version line only: 2026_08_28_120000 → 2026_11_02_120000.

  • db/schema_sqlite.rb — version line only, same change.

  • test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb — new; the first migration test in the repository. The default test/**/*_test.rb glob picks it up.

  • test/controllers/cards/boards_controller_test.rb — two new request-level tests.

Proof

  • A user with a draft on board A and a draft on board B moves the draft from A to B; the move succeeds and both drafts exist on board B → test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb a draft moves to a board where its creator already has a draft once the index is gone (regression test, SQLite, fails before the migration exists) and test/controllers/cards/boards_controller_test.rb update moves a draft to a board where its creator has another draft (both adapters).
  • A user with one draft on board A moves it to board B, which has no draft of theirs; the move succeeds → test/controllers/cards/boards_controller_test.rb update moves a draft to a board without a draft of its creator.
  • Running the migration on a database without the index does not raise → test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb does nothing when the drafted card index is absent (both adapters).
  • Running the migration on a database with the index removes it → test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb removes the drafted card index when present (SQLite).
  • Invariant: the migration runs no DDL on a database without the index, enforced by if_exists: true → test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb does nothing when the drafted card index is absent (asserts the index list of cards does not change and no DROP INDEX query runs, via assert_no_queries_match(/DROP INDEX/i, include_schema: true)).
  • Invariant: the test setup reproduces the production failure, enforced by the characterization test → test/db/migrate/remove_board_creator_drafted_index_from_cards_test.rb the drafted card index rejects moving a draft onto a board with another draft of its creator (asserts RecordNotUnique matching cards.board_id, cards.creator_id).

@eirvandelden
eirvandelden marked this pull request as ready for review November 2, 2026 15:23
@eirvandelden
eirvandelden force-pushed the move-draft-card-between-boards branch from 935a800 to 7d5f73d Compare November 3, 2026 08:35
eirvandelden and others added 7 commits November 6, 2026 21:52
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er draft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ruby/setup-ruby has no Ruby 3.4.8 build for ubuntu-26.04, which
ubuntu-latest now resolves to, so every job that sets up Ruby failed
before running anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@eirvandelden
eirvandelden force-pushed the move-draft-card-between-boards branch from 7d5f73d to 6fb0b02 Compare November 6, 2026 20:54
@eirvandelden
eirvandelden merged commit 2a07431 into main Nov 6, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant